// Search.go - implements web UI's search functionality.

package main

import (
    "./cache"
    "config"
    "database/sql"
    "errors"
    "fmt"
    "html/template"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
    "unicode"
    "yotsuba"
)

// Assemble search query and return results.
func search(w http.ResponseWriter, r *http.Request, tmpl *template.Template,
    board *config.Board) (cache.PageBuilder, time.Duration) {

    start := time.Now() // Begin clocking page construction.
    r.ParseForm()
    var pageNo int
    if _, ok := r.Form["page"]; ok {
        var e error
        pageNo, e = strconv.Atoi(r.Form["page"][0])
        if e != nil {
            errorPage(w, tmpl, "Invalid page.",
                "Invalid page number.")
            return nil, 0
        }
        delete(r.Form, "page")
    }

    searchQuery, params, e := buildQuery(r.Form, board, pageNo)
    if e != nil {
        errorPage(w, tmpl, e.Error(), e.Error())
        return nil, 0
    }

    // get matching posts
    t := new(yotsuba.SqlThread)
    posts := []*yotsuba.SqlPost{}

    // Opening a new temporary DB connection for searches. This may well turn
    // out to be a Bad Thing in the future but the sql package has some kind of
    // problem with me sharing out the DB handle created during the init
    // function in query.go even though passing around prepared queries is
    // fine; it cause a panic that runs through the standard sql package all
    // the way into some low level sync/mutex code.
    os.Setenv("PGHOST", "/var/run/postgresql")
    dbh, e := sql.Open("postgres", "dbname=nih sslmode=disable")
    chk(e)
    defer dbh.Close()

    //fmt.Println(searchQuery)
    rows, e := dbh.Query(searchQuery, params...)
    if e != nil {
        errorPage(w, tmpl, "Database is busy",
            "Database is busy. Try back in a moment.　すみません。")
        return nil, 0.0
    }

    resultCount := &sql.NullInt64{0, true}
    for rows.Next() {
        p := new(yotsuba.SqlPost)
        p, t, resultCount = buildPost(board, rows)
        posts = append(posts, p)
    }

    // Remove page field from base URL in footer
    values, _ := url.ParseQuery(r.URL.RawQuery)
    values.Del("page")
    queryString := values.Encode()

    finish := time.Now()
    pageData := &unprocessedPage{
        BoardName:   board.Abbrev,
        Boards:      boardList(),
        Title:       board.Title,
        Generated:   finish,
        TimeElapsed: int64(finish.Sub(start).Seconds() * 1000),
        Search:      zeroSearchParam(r.Form),
        NearEnd:     int(int(resultCount.Int64)/board.SearchPosts) == pageNo,
        PageBase:    r.URL.Path + "?" + queryString + "&page=",
        PageNo:      pageNo,
        Pages: pageRange(resultCount.Int64, board.SearchPosts,
            board.SearchPages, pageNo),
        ResultTotal: int(resultCount.Int64),
    }
    t.Tail = posts
    pageData.Threads = append(pageData.Threads, t)
    return func(wh io.Writer) error {
        return tmpl.ExecuteTemplate(wh, "search_results", pageData)
    }, board.SearchCacheTime
}

func buildQuery(form url.Values, board *config.Board, pageNo int) (
    string, []interface{}, error) {
    var where []string
    var whereClause string
    var err error
    var params []interface{}
    first := true
    bname := board.TableName

    searchBase := `
WITH search_results AS (
    SELECT *, COUNT(post_id) OVER() as result_count
    FROM ` + bname + ` WHERE ||$where$||
    ORDER BY time DESC LIMIT $1 OFFSET $2
)
SELECT int_id, post_id, ghost_id, time, name, trip, subject, mail, poster_uid,
       body, spoiler, post_deleted, parent, image, img_name, img_url, 
       highlight, auth, NULL, NULL, NULL, NULL, NULL, NULL, NULL, result_count
FROM search_results`
    params = append(params, board.SearchPosts, board.SearchPosts*pageNo)

    // loop over cgi params and append WHERE clause conditions accordingly.
    // create corresponding slice of parameters to send to query.
    for k, v := range form {
        if v[0] == "" {
            continue
        }
        curVar := v[0]

        // pq.go apparently can't handle positional parameters so I have to
        // track the param number every iteration with sqlVar.
        sqlVar := fmt.Sprintf("$%d", len(params)+1)
        if first {
            first = false
        } else {
            where = append(where, " AND ")
        }
        searchBase, whereClause, curVar, err =
            modSearchClause(searchBase, curVar, sqlVar, k)
        if err != nil {
            return "", params, err
        }
        where = append(where, whereClause)
        params = append(params, curVar)
    }
    if len(where) < 1 {
        return "", params, errors.New("No fields selected for search.")
    }
    return strings.Replace(searchBase, `||$where$||`,
        strings.Join(where, ""), -1), params, nil
}

func modSearchClause(searchBase, curVar, sqlVar, key string) (
    string, string, string, error) {
    var whereClause string
    switch key {
    case "post_id":
        notDigit := func(r rune) bool {
            return !unicode.IsDigit(r)
        }
        curVar = strings.TrimFunc(curVar, notDigit)
        if _, e := strconv.Atoi(curVar); e != nil {
            return "", "", "", errors.New("Invalid post number.")
        }
        whereClause = `post_id = ` + sqlVar
    case "trip":
        whereClause = `trip ILIKE ` + sqlVar
        curVar = "%" + curVar + "%"
    case "user_name":
        whereClause = `name ILIKE ` + sqlVar
        curVar = "%" + curVar + "%"
    case "file_name":
        whereClause = `img_name ILIKE ` + sqlVar
        curVar = "%" + curVar + "%"
    case "text_search":
        curVar = abridgeQuery(curVar)
        whereClause = `ts_body @@ plainto_tsquery(` + sqlVar + `)`
        searchBase =
            strings.Replace(searchBase, "body,",
                `ts_headline(body, plainto_tsquery(`+sqlVar+`),
                'StartSel=[hi], StopSel=[/hi], HighlightAll=TRUE'),`,
                -1)
    case "subject_search":
        curVar = abridgeQuery(curVar)
        whereClause = `ts_subject @@ plainto_tsquery(` + sqlVar + `)`
        searchBase =
            strings.Replace(searchBase, "subject,",
                `ts_headline(subject, plainto_tsquery(`+sqlVar+`),
                'StartSel=[hi], StopSel=[/hi], HighlightAll=TRUE'),`,
                -1)
    default:
        return "", "", "", errors.New("Invalid search parameter.")
    }
    return searchBase, whereClause, curVar, nil
}

// Initialize zero values for missing search paramters in a url.Values struct.
// and convert to map[string]string. This is to avoid having to index into
// every param and do a dozen checks for existence in the search template.
func zeroSearchParam(given url.Values) map[string]string {
    fullMap := map[string]string{}
    fields := []string{"text_search", "subject_search", "file_name",
        "post_id", "user_name", "trip"}
    for _, k := range fields {
        if val, ok := given[k]; ok {
            fullMap[k] = val[0]
        } else {
            fullMap[k] = ""
        }
    }
    return fullMap
}

// Full text search performs exponentially worse for every word to match.
// abridgeQuery does what it says to avoid overly long search queries tying up
// the server.
func abridgeQuery(s string) string {
    max := config.Defaults.FtsMaxWords
    terms := strings.Fields(s)

    if max > len(terms) {
        return s
    }
    return strings.Join(terms[:max], " ")
}
